在 Day 02 我們建立了 xUnit 的基礎知識,包含框架選擇、基本使用方式,以及建立第一個測試專案。今天我們要深入 xUnit 的進階功能,學習如何管理複雜的測試資料、優化測試執行性能,以及掌握可維護、可擴展的測試開發技術。
為什麼需要進階測試資料管理?
隨著專案規模成長,你會發現:
在 Day 02 我們學習了 InlineData 的基本用法:
[Theory]
[InlineData(1, 2, 3)]
[InlineData(-1, 1, 0)]
[InlineData(0, 0, 0)]
public void Add_基本測試_應回傳正確結果(int a, int b, int expected)
{
// 測試邏輯
}
InlineData 的限制:
public class CalculatorAdvancedTests
{
private readonly Calculator _calculator;
public CalculatorAdvancedTests()
{
_calculator = new Calculator();
}
// 使用靜態屬性提供測試資料
public static IEnumerable<object[]> AddTestData =>
new List<object[]>
{
new object[] { 1, 2, 3 },
new object[] { -1, 1, 0 },
new object[] { 0, 0, 0 },
new object[] { 100, 200, 300 },
new object[] { int.MaxValue, 1, (long)int.MaxValue + 1 } // 溢位測試
};
[Theory]
[MemberData(nameof(AddTestData))]
public void Add_使用MemberData_應回傳正確結果(int a, int b, long expected)
{
// Act
var result = _calculator.Add(a, b);
// Assert
Assert.Equal(expected, result);
}
}
public class StringValidationTests
{
// 使用靜態方法動態產生測試資料
public static IEnumerable<object[]> GetEmailTestData()
{
// 有效的 Email 格式
yield return new object[] { "test@example.com", true };
yield return new object[] { "user.name@company.co.uk", true };
yield return new object[] { "admin+tag@service.org", true };
// 無效的 Email 格式
yield return new object[] { "invalid-email", false };
yield return new object[] { "@example.com", false };
yield return new object[] { "test@", false };
yield return new object[] { "", false };
yield return new object[] { null, false };
// 邊界值測試
yield return new object[] { new string('a', 64) + "@example.com", true }; // 最大長度
yield return new object[] { new string('a', 65) + "@example.com", false }; // 超過最大長度
}
[Theory]
[MemberData(nameof(GetEmailTestData))]
public void IsValidEmail_各種格式_應回傳正確驗證結果(string email, bool expected)
{
// Arrange
var validator = new EmailValidator();
// Act
var result = validator.IsValidEmail(email);
// Assert
Assert.Equal(expected, result);
}
}
public static class CommonTestData
{
public static IEnumerable<object[]> GetValidUserData()
{
yield return new object[]
{
new User { Name = "John Doe", Email = "john@example.com", Age = 30 }
};
yield return new object[]
{
new User { Name = "Jane Smith", Email = "jane@company.com", Age = 25 }
};
}
public static IEnumerable<object[]> GetInvalidUserData()
{
yield return new object[]
{
new User { Name = "", Email = "john@example.com", Age = 30 } // 空名稱
};
yield return new object[]
{
new User { Name = "John", Email = "invalid-email", Age = 30 } // 無效 Email
};
yield return new object[]
{
new User { Name = "John", Email = "john@example.com", Age = -1 } // 無效年齡
};
}
}
public class UserServiceTests
{
[Theory]
[MemberData(nameof(CommonTestData.GetValidUserData), MemberType = typeof(CommonTestData))]
public void CreateUser_有效使用者資料_應成功建立(User user)
{
// 測試邏輯
}
[Theory]
[MemberData(nameof(CommonTestData.GetInvalidUserData), MemberType = typeof(CommonTestData))]
public void CreateUser_無效使用者資料_應拋出例外(User user)
{
// 測試邏輯
}
}
public class CalculationTestData : IEnumerable<object[]>
{
public IEnumerator<object[]> GetEnumerator()
{
// 基本運算測試
yield return new object[] { 10, 2, 5.0, "divide" };
yield return new object[] { 7, 2, 3.5, "divide" };
yield return new object[] { -10, 2, -5.0, "divide" };
// 乘法測試
yield return new object[] { 5, 3, 15.0, "multiply" };
yield return new object[] { -2, 4, -8.0, "multiply" };
yield return new object[] { 0, 100, 0.0, "multiply" };
// 邊界值測試
yield return new object[] { double.MaxValue, 2, double.MaxValue / 2, "divide" };
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Theory]
[ClassData(typeof(CalculationTestData))]
public void Calculate_使用ClassData_應回傳正確結果(double a, double b, double expected, string operation)
{
// Arrange
var calculator = new Calculator();
// Act
var result = operation switch
{
"divide" => calculator.Divide(a, b),
"multiply" => calculator.Multiply(a, b),
_ => throw new ArgumentException("Unknown operation")
};
// Assert
Assert.Equal(expected, result, precision: 2);
}
public class DatabaseConnectionTestData : IEnumerable<object[]>
{
private readonly string _connectionString;
public DatabaseConnectionTestData(string connectionString = "DefaultConnection")
{
_connectionString = connectionString;
}
public IEnumerator<object[]> GetEnumerator()
{
// 根據不同的連線字串產生不同的測試資料
if (_connectionString == "DefaultConnection")
{
yield return new object[] { "SELECT * FROM Users", 10 };
yield return new object[] { "SELECT COUNT(*) FROM Products", 1 };
}
else
{
yield return new object[] { "SELECT * FROM TestUsers", 5 };
yield return new object[] { "SELECT COUNT(*) FROM TestProducts", 1 };
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
public class CsvTestData : IEnumerable<object[]>
{
private readonly string _csvFilePath;
public CsvTestData(string csvFilePath = "TestData/calculations.csv")
{
_csvFilePath = csvFilePath;
}
public IEnumerator<object[]> GetEnumerator()
{
if (!File.Exists(_csvFilePath))
{
// 如果檔案不存在,提供預設測試資料
yield return new object[] { 1, 2, 3 };
yield return new object[] { 5, 5, 10 };
yield break;
}
var lines = File.ReadAllLines(_csvFilePath);
foreach (var line in lines.Skip(1)) // 跳過標題行
{
var values = line.Split(',');
if (values.Length >= 3 &&
int.TryParse(values[0], out var a) &&
int.TryParse(values[1], out var b) &&
int.TryParse(values[2], out var expected))
{
yield return new object[] { a, b, expected };
}
}
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Theory]
[ClassData(typeof(CsvTestData))]
public void Add_使用CSV資料_應回傳正確結果(int a, int b, int expected)
{
// 測試邏輯
}
public class ComplexObjectTests
{
// 複雜物件的測試資料
public static IEnumerable<object[]> UserScenarios
{
get
{
yield return new object[]
{
new User
{
Name = "Admin User",
Email = "admin@company.com",
Roles = new[] { "Admin", "User" },
Settings = new UserSettings { Theme = "Dark", Language = "zh-TW" }
},
true // 預期結果:可以存取管理功能
};
yield return new object[]
{
new User
{
Name = "Regular User",
Email = "user@company.com",
Roles = new[] { "User" },
Settings = new UserSettings { Theme = "Light", Language = "en-US" }
},
false // 預期結果:無法存取管理功能
};
}
}
[Theory]
[MemberData(nameof(UserScenarios))]
public void CanAccessAdminFeatures_不同使用者角色_應回傳正確權限(User user, bool expected)
{
// Arrange
var authService = new AuthorizationService();
// Act
var result = authService.CanAccessAdminFeatures(user);
// Assert
Assert.Equal(expected, result);
}
}
public class UserBuilder
{
private string _name = "Default User";
private string _email = "default@example.com";
private int _age = 25;
private List<string> _roles = new();
private UserSettings _settings = new();
public UserBuilder WithName(string name)
{
_name = name;
return this;
}
public UserBuilder WithEmail(string email)
{
_email = email;
return this;
}
public UserBuilder WithAge(int age)
{
_age = age;
return this;
}
public UserBuilder WithRole(string role)
{
_roles.Add(role);
return this;
}
public UserBuilder WithRoles(params string[] roles)
{
_roles.AddRange(roles);
return this;
}
public UserBuilder WithAdminRights()
{
return WithRoles("Admin", "User");
}
public UserBuilder WithSettings(UserSettings settings)
{
_settings = settings;
return this;
}
public User Build()
{
return new User
{
Name = _name,
Email = _email,
Age = _age,
Roles = _roles.ToArray(),
Settings = _settings
};
}
// 預設建立者方法
public static UserBuilder AUser() => new();
public static UserBuilder AnAdminUser() => new UserBuilder().WithAdminRights();
public static UserBuilder ARegularUser() => new UserBuilder().WithRole("User");
}
public class UserServiceTests
{
[Fact]
public void CreateUser_有效的管理員使用者_應成功建立()
{
// Arrange - 使用 Builder 模式建立測試資料
var adminUser = UserBuilder
.AnAdminUser()
.WithName("John Admin")
.WithEmail("john.admin@company.com")
.WithAge(35)
.Build();
var userService = new UserService();
// Act
var result = userService.CreateUser(adminUser);
// Assert
Assert.NotNull(result);
Assert.Equal("John Admin", result.Name);
Assert.Contains("Admin", result.Roles);
}
[Theory]
[MemberData(nameof(GetUserScenarios))]
public void ValidateUser_不同使用者情境_應回傳正確驗證結果(User user, bool expected)
{
// Arrange
var validator = new UserValidator();
// Act
var result = validator.IsValid(user);
// Assert
Assert.Equal(expected, result);
}
public static IEnumerable<object[]> GetUserScenarios()
{
// 有效使用者情境
yield return new object[]
{
UserBuilder.AUser()
.WithName("Valid User")
.WithEmail("valid@example.com")
.WithAge(25)
.Build(),
true
};
// 無效使用者情境 - 空名稱
yield return new object[]
{
UserBuilder.AUser()
.WithName("")
.WithEmail("valid@example.com")
.WithAge(25)
.Build(),
false
};
// 無效使用者情境 - 年齡過小
yield return new object[]
{
UserBuilder.AUser()
.WithName("Young User")
.WithEmail("young@example.com")
.WithAge(10)
.Build(),
false
};
}
}
其實這個 Test Builder 在已經有寫單元測試好一段時間的開發者來說,應該會想「這個模式不就是用 AutoFixture 的 Builder 方式嗎?」
這邊先介紹 Test Builder Pattern,之後會再介紹 AutoFixture。
Test Data Builder 模式是對 Object Mother 模式 的改良,解決了以下問題:
相關連結:
public interface ITestDataProvider<T>
{
IEnumerable<T> GetValidData();
IEnumerable<T> GetInvalidData();
IEnumerable<T> GetBoundaryData();
T GetSampleData();
}
public class UserTestDataProvider : ITestDataProvider<User>
{
public IEnumerable<User> GetValidData()
{
yield return UserBuilder.AUser()
.WithName("John Doe")
.WithEmail("john@example.com")
.WithAge(30)
.Build();
yield return UserBuilder.AnAdminUser()
.WithName("Admin User")
.WithEmail("admin@company.com")
.WithAge(35)
.Build();
}
public IEnumerable<User> GetInvalidData()
{
yield return UserBuilder.AUser()
.WithName("")
.WithEmail("john@example.com")
.WithAge(30)
.Build();
yield return UserBuilder.AUser()
.WithName("John")
.WithEmail("invalid-email")
.WithAge(30)
.Build();
}
public IEnumerable<User> GetBoundaryData()
{
yield return UserBuilder.AUser()
.WithAge(18) // 最小年齡
.Build();
yield return UserBuilder.AUser()
.WithAge(120) // 最大年齡
.Build();
}
public User GetSampleData()
{
return UserBuilder.AUser().Build();
}
}
public class UserValidationTests
{
private readonly ITestDataProvider<User> _userDataProvider;
private readonly UserValidator _validator;
public UserValidationTests()
{
_userDataProvider = new UserTestDataProvider();
_validator = new UserValidator();
}
[Theory]
[MemberData(nameof(GetValidUsers))]
public void ValidateUser_有效使用者_應通過驗證(User user)
{
// Act
var result = _validator.IsValid(user);
// Assert
Assert.True(result);
}
[Theory]
[MemberData(nameof(GetInvalidUsers))]
public void ValidateUser_無效使用者_應驗證失敗(User user)
{
// Act
var result = _validator.IsValid(user);
// Assert
Assert.False(result);
}
public static IEnumerable<object[]> GetValidUsers()
{
var provider = new UserTestDataProvider();
return provider.GetValidData().Select(user => new object[] { user });
}
public static IEnumerable<object[]> GetInvalidUsers()
{
var provider = new UserTestDataProvider();
return provider.GetInvalidData().Select(user => new object[] { user });
}
}
以下這個 DatabaseFixture 是用來建立測試用的 LocalDB
// 資料庫連線 Fixture
public class DatabaseFixture : IDisposable
{
public string ConnectionString { get; }
public IDbConnection Connection { get; }
public DatabaseFixture()
{
// 建立測試資料庫(只執行一次)
ConnectionString = CreateTestDatabase();
Connection = new SqlConnection(ConnectionString);
Connection.Open();
// 初始化測試資料
SeedTestData();
}
public void Dispose()
{
Connection?.Dispose();
CleanupTestDatabase();
}
private string CreateTestDatabase()
{
// 建立唯一的測試資料庫
var dbName = $"TestDb_{Guid.NewGuid():N}";
var masterConnection = "Server=(localdb)\\mssqllocaldb;Database=master;Trusted_Connection=true";
using var connection = new SqlConnection(masterConnection);
connection.Open();
var createDbCommand = $"CREATE DATABASE [{dbName}]";
using var command = new SqlCommand(createDbCommand, connection);
command.ExecuteNonQuery();
return $"Server=(localdb)\\mssqllocaldb;Database={dbName};Trusted_Connection=true";
}
private void SeedTestData()
{
// 建立測試資料表和初始資料
var createTableSql = @"
CREATE TABLE Users (
Id INT IDENTITY(1,1) PRIMARY KEY,
Name NVARCHAR(100) NOT NULL,
Email NVARCHAR(255) NOT NULL,
CreatedAt DATETIME2 DEFAULT GETDATE()
)";
using var command = new SqlCommand(createTableSql, Connection);
command.ExecuteNonQuery();
// 插入測試資料
var insertSql = "INSERT INTO Users (Name, Email) VALUES (@Name, @Email)";
using var insertCommand = new SqlCommand(insertSql, Connection);
var testUsers = new[]
{
("John Doe", "john@example.com"),
("Jane Smith", "jane@example.com"),
("Admin User", "admin@company.com")
};
foreach (var (name, email) in testUsers)
{
insertCommand.Parameters.Clear();
insertCommand.Parameters.AddWithValue("@Name", name);
insertCommand.Parameters.AddWithValue("@Email", email);
insertCommand.ExecuteNonQuery();
}
}
private void CleanupTestDatabase()
{
if (!string.IsNullOrEmpty(ConnectionString))
{
var builder = new SqlConnectionStringBuilder(ConnectionString);
var dbName = builder.InitialCatalog;
var masterConnection = "Server=(localdb)\\mssqllocaldb;Database=master;Trusted_Connection=true";
using var connection = new SqlConnection(masterConnection);
connection.Open();
var dropDbCommand = $"DROP DATABASE IF EXISTS [{dbName}]";
using var command = new SqlCommand(dropDbCommand, connection);
command.ExecuteNonQuery();
}
}
}
public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
private readonly DatabaseFixture _databaseFixture;
private readonly UserRepository _repository;
public UserRepositoryTests(DatabaseFixture databaseFixture)
{
_databaseFixture = databaseFixture;
_repository = new UserRepository(_databaseFixture.ConnectionString);
}
[Fact]
public void GetAllUsers_應回傳所有測試使用者()
{
// Act
var users = _repository.GetAllUsers();
// Assert
Assert.NotEmpty(users);
Assert.Equal(3, users.Count()); // 我們在 Fixture 中插入了 3 個使用者
Assert.Contains(users, u => u.Name == "John Doe");
Assert.Contains(users, u => u.Name == "Jane Smith");
Assert.Contains(users, u => u.Name == "Admin User");
}
[Fact]
public void GetUserByEmail_存在的Email_應回傳對應使用者()
{
// Arrange
var email = "john@example.com";
// Act
var user = _repository.GetUserByEmail(email);
// Assert
Assert.NotNull(user);
Assert.Equal("John Doe", user.Name);
Assert.Equal(email, user.Email);
}
[Fact]
public void CreateUser_新使用者_應成功建立()
{
// Arrange
var newUser = new User
{
Name = "Test User",
Email = "test@example.com"
};
// Act
var createdUser = _repository.CreateUser(newUser);
// Assert
Assert.NotNull(createdUser);
Assert.True(createdUser.Id > 0);
Assert.Equal(newUser.Name, createdUser.Name);
Assert.Equal(newUser.Email, createdUser.Email);
// 驗證資料確實儲存到資料庫
var retrievedUser = _repository.GetUserByEmail(newUser.Email);
Assert.NotNull(retrievedUser);
Assert.Equal(newUser.Name, retrievedUser.Name);
}
}
有關資料存取層的測試,上面的範例是使用 LocalDB 建立測試用資料庫,但這有個環境限制,因為 LocalDB 只能在 Windows 環境下建立與執行。
所以比較適當的方式應該就是整合 Testcontainers
或 .NET Aspire
,這在後續的篇章裡將會介紹。
至於有些人會提出說可以使用 SQLite 的方式來替代,我只能說... 簡單的 CRUD 處理的測試是可以這樣處理,但實際上工作專案所面臨到的資料處理並沒有那麼單純,複雜一點的 SQL 指令,這個 SQLite 就無法提供相對映的操作功能。
// 使用 WebApplicationFactory 的整合測試 Fixture
public class WebApiFixture : IDisposable
{
public HttpClient Client { get; }
public WebApplicationFactory<Program> Factory { get; }
public WebApiFixture()
{
// 使用 WebApplicationFactory 建立測試伺服器
Factory = new WebApplicationFactory<Program>()
.WithWebHostBuilder(builder =>
{
builder.UseEnvironment("Testing");
// 覆寫服務註冊以使用測試專用的實作
builder.ConfigureServices(services =>
{
// 移除原本的資料庫註冊
var descriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null)
services.Remove(descriptor);
// 使用 In-Memory 資料庫
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));
// 替換外部服務為 Mock 實作
services.AddScoped<IEmailService, MockEmailService>();
services.AddScoped<IPaymentService, MockPaymentService>();
});
// 設定測試專用的組態
builder.ConfigureAppConfiguration((context, config) =>
{
config.AddInMemoryCollection(new Dictionary<string, string>
{
["ConnectionStrings:DefaultConnection"] = "InMemoryTestDb",
["ApiKeys:ExternalService"] = "test-api-key",
["Features:EnableNewFeature"] = "true"
});
});
});
// 建立 HTTP 客戶端
Client = Factory.CreateClient();
// 初始化測試資料
SeedTestData();
}
private void SeedTestData()
{
using var scope = Factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 確保資料庫已建立
dbContext.Database.EnsureCreated();
// 插入測試資料
var testUsers = new[]
{
new User { Name = "API Test User 1", Email = "apitest1@example.com" },
new User { Name = "API Test User 2", Email = "apitest2@example.com" }
};
dbContext.Users.AddRange(testUsers);
dbContext.SaveChanges();
}
// 取得服務實例的輔助方法
public T GetService<T>() where T : notnull
{
return Factory.Services.GetRequiredService<T>();
}
// 建立有認證的客戶端
public HttpClient CreateAuthenticatedClient(string userId = "test-user")
{
var client = Factory.CreateClient();
// 加入 JWT Token 或其他認證資訊
var token = GenerateTestJwtToken(userId);
client.DefaultRequestHeaders.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
return client;
}
private string GenerateTestJwtToken(string userId)
{
// 產生測試用的 JWT Token
// 實際專案中可以使用 JwtSecurityTokenHandler
return "test-jwt-token";
}
public void Dispose()
{
Client?.Dispose();
Factory?.Dispose();
}
}
// 定義 Collection
[CollectionDefinition("WebApi Collection")]
public class WebApiCollection : ICollectionFixture<WebApiFixture>
{
// 這個類別不需要實作任何程式碼
// 它只是用來定義 Collection 和關聯的 Fixture
}
[Collection("WebApi Collection")]
public class UsersControllerTests
{
private readonly WebApiFixture _fixture;
private readonly HttpClient _client;
public UsersControllerTests(WebApiFixture fixture)
{
_fixture = fixture;
_client = _fixture.Client;
}
[Fact]
public async Task GetUsers_應回傳所有使用者()
{
// Act
var response = await _client.GetAsync("/api/users");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var users = JsonSerializer.Deserialize<User[]>(content, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
Assert.NotNull(users);
Assert.Equal(2, users.Length);
}
[Fact]
public async Task CreateUser_有效使用者資料_應成功建立()
{
// Arrange
var newUser = new { Name = "New API User", Email = "newapi@example.com" };
var json = JsonSerializer.Serialize(newUser);
var content = new StringContent(json, Encoding.UTF8, "application/json");
// Act
var response = await _client.PostAsync("/api/users", content);
// Assert
response.EnsureSuccessStatusCode();
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
// 驗證回應內容
var responseContent = await response.Content.ReadAsStringAsync();
var createdUser = JsonSerializer.Deserialize<User>(responseContent, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
Assert.NotNull(createdUser);
Assert.Equal(newUser.Name, createdUser.Name);
Assert.Equal(newUser.Email, createdUser.Email);
Assert.True(createdUser.Id > 0);
// 驗證資料確實儲存到資料庫
using var scope = _fixture.Factory.Services.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var savedUser = await dbContext.Users.FirstOrDefaultAsync(u => u.Email == newUser.Email);
Assert.NotNull(savedUser);
Assert.Equal(newUser.Name, savedUser.Name);
}
}
[Collection("WebApi Collection")]
public class ProductsControllerTests
{
private readonly WebApiFixture _fixture;
private readonly HttpClient _client;
public ProductsControllerTests(WebApiFixture fixture)
{
_fixture = fixture;
_client = _fixture.Client;
}
[Fact]
public async Task GetProducts_應回傳產品清單()
{
// 這個測試會與 UsersControllerTests 共享同一個 WebApiFixture 實例
// 但每個測試類別仍然有獨立的測試實例
// Act
var response = await _client.GetAsync("/api/products");
// Assert
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
}
[Fact]
public async Task GetProducts_需要認證_未認證應回傳401()
{
// Act
var response = await _client.GetAsync("/api/products/private");
// Assert
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task GetProducts_使用認證客戶端_應成功存取()
{
// Arrange
using var authenticatedClient = _fixture.CreateAuthenticatedClient("admin-user");
// Act
var response = await authenticatedClient.GetAsync("/api/products/private");
// Assert
response.EnsureSuccessStatusCode();
var content = await response.Content.ReadAsStringAsync();
var products = JsonSerializer.Deserialize<Product[]>(content, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
Assert.NotNull(products);
}
}
上面的例子有使用到 WebApplicationFactory 做整合測試,有關 WebApplicationFactory 與整合測試的部分,之後其他天數的文章裡會做介紹。
public class DatabaseFixture : IDisposable
{
public string ConnectionString { get; }
public AppDbContext DbContext { get; }
public DatabaseFixture()
{
// 建立測試專用的資料庫連線
ConnectionString = CreateTestDatabase();
var options = new DbContextOptionsBuilder<AppDbContext>()
.UseSqlServer(ConnectionString)
.Options;
DbContext = new AppDbContext(options);
DbContext.Database.EnsureCreated();
// 初始化測試資料
SeedTestData();
}
private void SeedTestData()
{
var testUsers = new[]
{
new User { Name = "Test User 1", Email = "test1@example.com" },
new User { Name = "Test User 2", Email = "test2@example.com" }
};
DbContext.Users.AddRange(testUsers);
DbContext.SaveChanges();
}
public void CleanupData()
{
// 在需要時清理測試資料
DbContext.Users.RemoveRange(DbContext.Users);
DbContext.SaveChanges();
}
public void Dispose()
{
DbContext?.Dispose();
CleanupTestDatabase();
}
private string CreateTestDatabase()
{
var dbName = $"TestDb_{Guid.NewGuid():N}";
return $"Server=(localdb)\\mssqllocaldb;Database={dbName};Trusted_Connection=true";
}
private void CleanupTestDatabase()
{
// 清理測試資料庫的邏輯
}
}
public class ServiceFixture : IDisposable
{
public IServiceProvider ServiceProvider { get; }
public ServiceFixture()
{
var services = new ServiceCollection();
// 註冊測試所需的服務
services.AddScoped<IUserService, UserService>();
services.AddScoped<IEmailService, MockEmailService>();
services.AddDbContext<AppDbContext>(options =>
options.UseInMemoryDatabase($"TestDb_{Guid.NewGuid()}"));
// 建立服務提供者
ServiceProvider = services.BuildServiceProvider();
// 初始化資料庫
using var scope = ServiceProvider.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
dbContext.Database.EnsureCreated();
SeedData(dbContext);
}
private void SeedData(AppDbContext dbContext)
{
// 插入測試資料
}
public T GetService<T>() where T : notnull
{
return ServiceProvider.GetRequiredService<T>();
}
public void Dispose()
{
if (ServiceProvider is IDisposable disposable)
{
disposable.Dispose();
}
}
}
public class ServiceIntegrationTests : IClassFixture<ServiceFixture>
{
private readonly ServiceFixture _fixture;
public ServiceIntegrationTests(ServiceFixture fixture)
{
_fixture = fixture;
}
[Fact]
public void UserService_建立使用者_應發送歡迎郵件()
{
// Arrange
using var scope = _fixture.ServiceProvider.CreateScope();
var userService = scope.ServiceProvider.GetRequiredService<IUserService>();
var emailService = scope.ServiceProvider.GetRequiredService<IEmailService>() as MockEmailService;
var newUser = new User { Name = "Test User", Email = "test@example.com" };
// Act
userService.CreateUser(newUser);
// Assert
Assert.Single(emailService.SentEmails);
Assert.Contains("歡迎", emailService.SentEmails.First().Subject);
}
}
xUnit 預設會嘗試並行執行測試以提升效率,但有一些重要的規則需要了解:
// 預設情況:這兩個類別的測試可能會並行執行
public class UserServiceTests
{
[Fact]
public void Test1() { /* 可能與 ProductServiceTests.Test2 並行執行 */ }
}
public class ProductServiceTests
{
[Fact]
public void Test2() { /* 可能與 UserServiceTests.Test1 並行執行 */ }
}
當測試需要共享資源(如資料庫)時,使用 Collection 確保它們不會並行執行:
// 使用相同 Collection 的測試不會並行執行
[Collection("Database Tests")]
public class UserRepositoryTests
{
[Fact]
public void Test1() { /* 不會與 ProductRepositoryTests 的測試並行執行 */ }
}
[Collection("Database Tests")]
public class ProductRepositoryTests
{
[Fact]
public void Test2() { /* 不會與 UserRepositoryTests 的測試並行執行 */ }
}
對於需要嚴格控制執行順序的測試:
[Collection("Sequential Tests")]
public class IntegrationTests
{
[Fact]
public void Test_Step1() { }
[Fact]
public void Test_Step2() { }
}
[CollectionDefinition("Sequential Tests", DisableParallelization = true)]
public class SequentialCollection : ICollectionFixture<object>
{
// 此 Collection 中的所有測試將完全依序執行
}
可以透過 xunit.runner.json
檔案調整並行執行行為:
{
"parallelizeTestCollections": true,
"maxParallelThreads": 4
}
實用建議:
在學習了 Theory 進階資料提供機制、Builder 模式、資料提供者模式,以及 Fixture 資源管理後,以下是完整的選擇建議:
[Theory]
[InlineData(1, 2, 3)]
[InlineData(-1, 1, 0)]
public void Add_基本運算_應回傳正確結果(int a, int b, int expected)
[Theory]
[MemberData(nameof(GetUserTestData))]
public void ValidateUser_不同情境_應回傳正確結果(User user, bool expected)
[Theory]
[ClassData(typeof(UserValidationTestData))]
public void ProcessUser_複雜情境_應正確處理(User user, ValidationResult expected)
var user = UserBuilder.AUser()
.WithName("Test User")
.WithValidEmail()
.Build();
public class UserRepositoryTests : IClassFixture<DatabaseFixture>
{
// 測試會共享同一個資料庫實例
}
[Collection("WebApi Collection")]
public class UsersControllerTests { }
[Collection("WebApi Collection")]
public class ProductsControllerTests { }
從最簡單開始:
根據複雜度調整:
整合測試考量:
效能最佳化:
明天我們將介紹AwesomeAssertions 測試斷言工具深度應用,包括:
今天我們深入了 xUnit 的進階領域,這些技術不只是「會用」就好,更重要的是「用對地方」。
好的測試架構就像好的軟體架構一樣,需要前期投資,但會在長期帶來巨大回報。
範例程式碼:
這是「重啟挑戰:老派軟體工程師的測試修練」系列的第三天。明天我們將探索 Assertion 工具的深度應用與選擇策略!